package com.endava.cats.model;

import com.endava.cats.http.HttpMethod;
import com.endava.cats.util.CatsModelUtils;
import com.endava.cats.util.JsonUtils;
import io.github.ludovicianul.prettylogger.PrettyLogger;
import io.github.ludovicianul.prettylogger.PrettyLoggerFactory;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.media.Schema;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;

import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

/**
 * Represents data used in the fuzzing process.
 */
@Builder(toBuilder = true)
@Getter
@ToString
public class FuzzingData {
    /**
     * Represents an empty string.
     */
    public static final String EMPTY = "";
    private final PrettyLogger logger = PrettyLoggerFactory.getLogger(FuzzingData.class);
    private final HttpMethod method;
    private final String contractPath;
    private final String path;
    private final Set<CatsHeader> headers;
    private final String payload;
    private final Set<String> responseCodes;
    private final Schema reqSchema;
    private final PathItem pathItem;
    private final Map<String, Schema> schemaMap;
    private final Map<String, List<String>> responses;
    private final Map<String, Schema> requestPropertyTypes;
    private final List<String> requestContentTypes;
    private final Set<String> queryParams;
    private final OpenAPI openApi;
    private final List<String> tags;
    private final String reqSchemaName;
    /*these are cached after the first computation*/
    private Set<String> allFields;
    private Set<Set<String>> allFieldsSetOfSets;
    private List<String> allRequiredFields;
    private Set<CatsField> allFieldsAsCatsFields;
    private Set<String> allReadOnlyFields;
    private Set<String> allWriteOnlyFields;
    private String processedPayload;
    private Set<String> targetFields;
    private int selfReferenceDepth;
    private int limitNumberOfFields;
    private String pathParamsPayload;

    @Builder.Default
    private final Map<String, List<String>> responseContentTypes = Collections.emptyMap();

    @Builder.Default
    private List<String> includeFieldTypes = Collections.emptyList();

    @Builder.Default
    private List<String> skipFieldTypes = Collections.emptyList();

    @Builder.Default
    private List<String> includeFieldFormats = Collections.emptyList();

    @Builder.Default
    private List<String> skipFieldFormats = Collections.emptyList();

    @Builder.Default
    private List<String> skippedFieldsForAllFuzzers = Collections.emptyList();


    @Builder.Default
    private Set<Object> examples = new HashSet<>();

    @Builder.Default
    private Map<String, Set<String>> responseHeaders = Collections.emptyMap();

    /**
     * Checks if the given field is a query param.
     *
     * @param field the name of the field
     * @return true if field is query param, false otherwise
     */
    public boolean isQueryParam(String field) {
        return this.queryParams.contains(field);
    }

    /**
     * Gets the processed payload associated with the fuzzing data.
     * <p>
     * If the processed payload is not already set, it will be generated by removing
     * elements related to read and write operations from the original payload.
     *
     * @return The processed payload.
     */
    public String getPayload() {
        if (processedPayload == null) {
            processedPayload = this.removeReadWrite();
        }

        return processedPayload;
    }

    private String removeReadWrite() {
        if (HttpMethod.requiresBody(method)) {
            return this.removeReadOnlyFields(this.getAllReadOnlyFields());
        }
        return this.removeReadOnlyFields(this.getAllWriteOnlyFields());
    }

    private String removeReadOnlyFields(Set<String> fieldsToRemove) {
        String result = payload;
        for (String readOnlyField : fieldsToRemove) {
            result = JsonUtils.deleteNode(result, readOnlyField);
        }

        return result;
    }

    private Set<CatsField> getFields(Schema<?> schema, String prefix) {
        logger.trace("Getting fields for prefix: {}", prefix);
        if (JsonUtils.isCyclicReference(prefix, selfReferenceDepth)) {
            return Collections.emptySet();
        }
        Set<CatsField> catsFields = new HashSet<>();
        if (schema.get$ref() != null) {
            schema = schemaMap.get(this.getDefinitionNameFromRef(schema.get$ref()));
        }

        if (schema == null) {
            return Collections.emptySet();
        }

        List<String> required = Optional.ofNullable(schema.getRequired()).orElseGet(Collections::emptyList);

        if (schema.getProperties() != null) {
            for (Map.Entry<String, Schema> prop : schema.getProperties().entrySet()) {
                catsFields.add(CatsField.builder()
                        .name(prefix.isEmpty() ? prop.getKey() : prefix + "#" + prop.getKey())
                        .schema(prop.getValue())
                        .required(required.contains(prop.getKey()))
                        .readOnly(Optional.ofNullable(prop.getValue().getReadOnly()).orElse(false))
                        .writeOnly(Optional.ofNullable(prop.getValue().getWriteOnly()).orElse(false))
                        .build());
                catsFields.addAll(this.getFields(prop.getValue(), prefix.isEmpty() ? prop.getKey() : prefix + "#" + prop.getKey()));
            }
        } else if (CatsModelUtils.isComposedSchema(schema)) {
            Optional.ofNullable(schema.getAllOf()).ifPresent(allOf -> allOf.forEach(item -> catsFields.addAll(this.getFields(item, prefix))));
            Optional.ofNullable(schema.getAnyOf()).ifPresent(anyOf -> anyOf.forEach(item -> catsFields.addAll(this.getFields(item, prefix))));
            Optional.ofNullable(schema.getOneOf()).ifPresent(oneOf -> oneOf.forEach(item -> catsFields.addAll(this.getFields(item, prefix))));
        }

        return catsFields.stream()
                .filter(catsField -> this.getRequestPropertyTypes().get(catsField.getName()) != null)
                .collect(Collectors.toSet());
    }


    private String getDefinitionNameFromRef(String ref) {
        return ref.substring(ref.lastIndexOf('/') + 1);
    }

    /**
     * Gets a set containing the names of all read-only fields associated with the fuzzing data.
     * <p>
     * If the set of all read-only fields is not already computed, it will be generated by filtering
     * the CatsFields representing read-only fields and extracting their names.
     *
     * @return A set containing the names of all read-only fields.
     */
    private Set<String> getAllReadOnlyFields() {
        if (allReadOnlyFields == null) {
            allReadOnlyFields = this.getAllFieldsAsCatsFields().stream().filter(CatsField::isReadOnly).map(CatsField::getName).collect(Collectors.toSet());
        }
        return allReadOnlyFields;
    }

    /**
     * Gets a set containing the names of all write-only fields associated with the fuzzing data.
     * <p>
     * If the set of all write-only fields is not already computed, it will be generated by filtering
     * the CatsFields representing write-only fields and extracting their names.
     *
     * @return A set containing the names of all write-only fields.
     */
    private Set<String> getAllWriteOnlyFields() {
        if (allWriteOnlyFields == null) {
            allWriteOnlyFields = this.getAllFieldsAsCatsFields().stream().filter(CatsField::isWriteOnly).map(CatsField::getName).collect(Collectors.toSet());
        }
        return allWriteOnlyFields;
    }

    /**
     * Gets a list containing the names of all required fields associated with the fuzzing data.
     * <p>
     * If the list of all required fields is not already computed, it will be generated by filtering
     * the CatsFields representing required fields and extracting their names.
     *
     * @return A list containing the names of all required fields.
     */
    public List<String> getAllRequiredFields() {
        if (allRequiredFields == null) {
            allRequiredFields = this.getAllFieldsAsCatsFields().stream().filter(CatsField::isRequired).map(CatsField::getName).toList();
        }
        return allRequiredFields;
    }

    /**
     * Gets a set of CatsFields representing all fields associated with the fuzzing data.
     * <p>
     * If the set of all fields is not already computed, it will be generated by obtaining the fields
     * from the specified request schema and filtering them based on inclusion and exclusion criteria.
     *
     * @return A set of CatsFields representing all fields associated with the fuzzing data.
     */
    public Set<CatsField> getAllFieldsAsCatsFields() {
        if (allFieldsAsCatsFields == null) {
            allFieldsAsCatsFields = this.getFields(reqSchema, EMPTY);
            if (!includeFieldTypes.isEmpty()) {
                allFieldsAsCatsFields.removeIf(catsField -> !includeFieldTypes.contains(Optional.ofNullable(catsField.getSchema().getType()).orElse(EMPTY)));
            }
            if (!includeFieldFormats.isEmpty()) {
                allFieldsAsCatsFields.removeIf(catsField -> !includeFieldFormats.contains(Optional.ofNullable(catsField.getSchema().getFormat()).orElse(EMPTY)));
            }
            allFieldsAsCatsFields.removeIf(catsField -> skipFieldTypes.contains(Optional.ofNullable(catsField.getSchema().getType()).orElse(EMPTY)));
            allFieldsAsCatsFields.removeIf(catsField -> skipFieldFormats.contains(Optional.ofNullable(catsField.getSchema().getFormat()).orElse(EMPTY)));
            allFieldsAsCatsFields.removeIf(catsField -> skippedFieldsForAllFuzzers.stream().anyMatch(skippedField -> catsField.getName().startsWith(skippedField)));
        }

        return allFieldsAsCatsFields;
    }

    /**
     * Gets a set of field names associated with the fuzzing data based on the HTTP method.
     * <p>
     * If the HTTP method requires a request body, the method returns fields that are not marked as read-only.
     * If the HTTP method does not require a request body, the method returns fields that are not marked as write-only.
     *
     * @return A set of field names associated with the fuzzing data based on the HTTP method.
     */
    public Set<String> getAllFieldsByHttpMethod() {
        if (HttpMethod.requiresBody(method)) {
            return getAllFields().stream().filter(field -> !this.getAllReadOnlyFields().contains(field)).collect(Collectors.toSet());
        }

        return getAllFields().stream().filter(field -> !this.getAllWriteOnlyFields().contains(field)).collect(Collectors.toSet());
    }

    private Set<String> getAllFields() {
        if (allFields == null) {
            allFields = this.getAllFieldsAsCatsFields().stream().map(CatsField::getName).collect(Collectors.toSet());
        }
        int limit = Math.min(allFields.size(), limitNumberOfFields <= 0 ? allFields.size() : limitNumberOfFields);

        allFields = allFields.stream()
                .collect(Collectors.collectingAndThen(
                        Collectors.toList(),
                        list -> {
                            Collections.shuffle(list);
                            return list.stream()
                                    .limit(limit)
                                    .collect(Collectors.toCollection(LinkedHashSet::new));
                        }
                ));

        return allFields;
    }

    /**
     * Gets a set of sets of field names based on the specified set fuzzing strategy and maximum fields to remove.
     *
     * @param setFuzzingStrategy The set fuzzing strategy to determine how sets of fields are generated.
     * @param maxFieldsToRemove  The maximum number of fields to remove when using the specified strategy.
     * @return A set of sets of field names generated using the specified set fuzzing strategy.
     */
    public Set<Set<String>> getAllFields(SetFuzzingStrategy setFuzzingStrategy, int maxFieldsToRemove) {
        if (allFieldsSetOfSets == null) {

            Set<Set<String>> sets = switch (setFuzzingStrategy) {
                case POWERSET -> SetFuzzingStrategy.powerSet(this.getAllFields());
                case SIZE -> SetFuzzingStrategy.getAllSetsWithMinSize(this.getAllFields(), maxFieldsToRemove);
                default -> SetFuzzingStrategy.removeOneByOne(this.getAllFields());
            };
            sets.remove(Collections.emptySet());
            allFieldsSetOfSets = sets;
        }
        return allFieldsSetOfSets;
    }

    /**
     * Returns the first element from the content types.
     *
     * @return the first element from the content types.
     */
    public String getFirstRequestContentType() {
        return requestContentTypes.getFirst();
    }

    /**
     * Enumeration representing different set fuzzing strategies for generating sets of field names.
     */
    public enum SetFuzzingStrategy {
        /**
         * Generates the powerset of the field names, considering all possible combinations.
         */
        POWERSET,

        /**
         * Generates sets of field names with a specified minimum size up to a maximum number of fields to remove.
         */
        SIZE,

        /**
         * Generates sets of field names by removing one field at a time.
         */
        ONEBYONE;

        private static final PrettyLogger LOGGER = PrettyLoggerFactory.getLogger(SetFuzzingStrategy.class);

        /**
         * Returns all possible subsets of the given set
         *
         * @param originalSet initial set
         * @param <T>         type of data within the set
         * @return a set of sets with all possible combinations
         */
        public static <T> Set<Set<T>> powerSet(Set<T> originalSet) {
            Set<Set<T>> sets = new HashSet<>();
            if (originalSet.isEmpty()) {
                sets.add(new HashSet<>());
                return sets;
            }
            List<T> list = new ArrayList<>(originalSet);
            T head = list.getFirst();
            Set<T> rest = new HashSet<>(list.subList(1, list.size()));
            for (Set<T> set : powerSet(rest)) {
                Set<T> newSet = new HashSet<>();
                newSet.add(head);
                newSet.addAll(set);
                sets.add(newSet);
                sets.add(set);
            }
            return sets;
        }


        /**
         * Returns all possible subsets of size K of the given list
         *
         * @param elements list of elements to accumulate
         * @param k        size
         * @return a set of sets
         */
        private static Set<Set<String>> getAllSubsetsOfSize(List<String> elements, int k) {
            List<List<String>> result = new ArrayList<>();
            backtrack(elements, k, 0, result, new ArrayList<>());
            return result.stream().map(HashSet::new).collect(Collectors.toSet());
        }

        private static void backtrack(List<String> elements, int k, int startIndex, List<List<String>> result,
                                      List<String> partialList) {
            if (k == partialList.size()) {
                result.add(new ArrayList<>(partialList));
                return;
            }
            for (int i = startIndex; i < elements.size(); i++) {
                partialList.add(elements.get(i));
                backtrack(elements, k, i + 1, result, partialList);
                partialList.removeLast();
            }
        }


        /**
         * Returns a Set of sets with possibilities of removing one field at a time form the original set.
         *
         * @param elements a given Set
         * @param <T>      the type of the elements
         * @return a Set of incremental Sets obtained by removing one element at a time from the original Set
         */
        public static <T> Set<Set<T>> removeOneByOne(Set<T> elements) {
            Set<Set<T>> result = new HashSet<>();
            for (T t : elements) {
                Set<T> subset = new HashSet<>();
                subset.add(t);
                result.add(subset);
            }
            return result;
        }

        /**
         * Returns all the sets obtained by removing at max {@code maxFieldsToRemove}
         *
         * @param allFields         all fields from the request, including fully qualified fields
         * @param maxFieldsToRemove number of max fields to remove
         * @return a Set of Sets with all fields combinations
         */
        public static Set<Set<String>> getAllSetsWithMinSize(Set<String> allFields, int maxFieldsToRemove) {
            Set<Set<String>> sets = new HashSet<>();
            if (maxFieldsToRemove == 0) {
                LOGGER.note("fieldsSubsetMinSize is ZERO, the value will be changed to {}", allFields.size() / 2);
                maxFieldsToRemove = allFields.size() / 2;
            } else if (allFields.size() < maxFieldsToRemove) {
                LOGGER.note("fieldsSubsetMinSize is bigger than the number of fields, the value will be changed to {}", allFields.size());
                maxFieldsToRemove = allFields.size();
            }
            for (int i = maxFieldsToRemove; i >= 1; i--) {
                sets.addAll(getAllSubsetsOfSize(new ArrayList<>(allFields), i));
            }
            return sets;
        }
    }

    /**
     * Returns the list of content types corresponding to the response code.
     *
     * @param responseCode the http response code
     * @return associated content types as defined in the contract
     */
    public List<String> getContentTypesByResponseCode(String responseCode) {
        return Optional.ofNullable(responseContentTypes.get(responseCode)).orElse(List.of("application/json"));
    }
}
